[WIP] feat: native subagent & workflow observability - #5219
Conversation
…imeline) Surface Claude Code subagents/workflows and Codex collab agents in the UI using only native provider emissions. Zero migrations, zero new tables: widened task.* activity payloads ride the existing event-sourced activity path, and a client-side fold in client-runtime derives v2-shaped subagent state (field names match #4779 so the orchestration-v2 merge is mechanical). Server: - contracts: TaskAgentLinkage on all task payloads, new task.updated event, typed RuntimeTaskUsage, tool attribution (agentId/parentToolUseId) - ClaudeAdapter: carry subagent_type/workflow_name/tool_use_id/outputFile, handle task_updated (was dropped), attribute subagent tool events via parent_tool_use_id, defensive workflow_progress parse, Workflow run handles - CodexSessionRuntime/Adapter: register multi-agent-v2 children from thread/started + subAgentActivity, intercept child notifications, and synthesize task.* lifecycle (idle=resumable, cumulative usage) [WIP: routing is probe-gated per spec] - ingestion: task.updated + agent-owned tool.progress persisted; wire-slim regression test proving agent fields survive to the client Web: - Agents right-panel surface (workflow phase groups, direct spawns, static status dots, DOM-write elapsed timers, expandable activity ring) - composer live strip + inline workflow run card (8-row urgency cap) - quiet timeline: one lifecycle row per agent (collapse by taskId), agent- attributed tool rows re-homed to the panel, timelineBypass rows suppressed Mobile: same quiet-timeline fold; task.completed kept as terminal signal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| return; | ||
| } | ||
|
|
||
| if (yield* interceptCollabChildNotification(notification)) { |
There was a problem hiding this comment.
🟡 Medium Layers/CodexSessionRuntime.ts:1079
Children tracked only via collabReceiverTurns (from a prior collabAgentToolCall) never emit the synthetic collabAgent/* lifecycle events this change introduces, so their task state stays missing or stuck. handleRawNotification returns early when shouldSuppressChildConversationNotification is true — which covers thread/started, turn/started, turn/completed, status changes, token usage, and thread/closed — before interceptCollabChildNotification runs at line 1079. Those children are never registered through thread/started, and even subAgentActivity-registered children never emit collabAgent/turnStarted, collabAgent/turnCompleted, collabAgent/statusChanged, collabAgent/tokenUsage, or collabAgent/closed because the suppression check intercepts those methods first. Consider moving interceptCollabChildNotification ahead of the childParentTurnId suppression check, or making the suppression skip notifications addressed to registered child threads.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/provider/Layers/CodexSessionRuntime.ts around line 1079:
Children tracked only via `collabReceiverTurns` (from a prior `collabAgentToolCall`) never emit the synthetic `collabAgent/*` lifecycle events this change introduces, so their task state stays missing or stuck. `handleRawNotification` returns early when `shouldSuppressChildConversationNotification` is true — which covers `thread/started`, `turn/started`, `turn/completed`, status changes, token usage, and `thread/closed` — before `interceptCollabChildNotification` runs at line 1079. Those children are never registered through `thread/started`, and even `subAgentActivity`-registered children never emit `collabAgent/turnStarted`, `collabAgent/turnCompleted`, `collabAgent/statusChanged`, `collabAgent/tokenUsage`, or `collabAgent/closed` because the suppression check intercepts those methods first. Consider moving `interceptCollabChildNotification` ahead of the `childParentTurnId` suppression check, or making the suppression skip notifications addressed to registered child threads.
ApprovabilityVerdict: Needs human review 6 blocking correctness issues found. Diff is too large for automated approval analysis. A human reviewer should evaluate this PR. You can customize Macroscope's approvability policy. Learn more. |
…w card Per live-test feedback the roster rendered three times at once (panel, card, strip). New rule: the Agents panel is the only roster. The chat gets one anchored CTA row per spawn batch (workflow run, or a turn's direct spawns): 'Kicked off N subagents · <workflow> — <phase> · N active · Σ tok — Open Agents'. Live status derives from the shared panel model at render time; the row freezes to past tense on settle. Strip and card components deleted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live-test round 2 fixes: - Shells/monitors/plan tasks no longer masquerade as subagents: taskType rides on every task payload (adapter linkage + ingestion allowlist) and the fold excludes non-agent task types from the roster. 'Run 12s stall' background shells stay in the ordinary work log. - The spawn CTA no longer says completed while a workflow is mid-flight: for workflow batches the coordinator's own terminal state is authoritative (dynamic spawns can make the known-member list momentarily all-settled). - Agents panel rows are flat status lines: the per-agent unfold (recent tool-call feed) is gone. The only expansion is run-granularity: settled workflow runs collapse to one summary line under 'Earlier', click to list members. Live workflows and direct spawns sort first in bordered sections with settled/total counts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Medium
t3code/apps/web/src/session-logic.ts
Line 845 in 16cdc33
In collapseDerivedWorkLogEntries, direct-spawn tasks whose turnId is absent all share the same direct:no-turn group key, so agents from different turns (or legacy rows) collapse into a single CTA row with a combined agentTaskIds list — distinct spawn events are silently merged. The fallback in agentSpawnGroupKey should be scoped to a per-batch or per-activity identifier rather than a global "no-turn" string.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/session-logic.ts around line 845:
In `collapseDerivedWorkLogEntries`, direct-spawn tasks whose `turnId` is absent all share the same `direct:no-turn` group key, so agents from different turns (or legacy rows) collapse into a single CTA row with a combined `agentTaskIds` list — distinct spawn events are silently merged. The fallback in `agentSpawnGroupKey` should be scoped to a per-batch or per-activity identifier rather than a global `"no-turn"` string.
| const taskId = asString(payload.taskId); | ||
| if (!taskId) break; | ||
| if (isBackgroundTaskActivity(payload)) break; | ||
| const agent = getOrCreate(agents, taskId, payload, at); |
There was a problem hiding this comment.
🟡 Medium state/subagentRuntime.ts:521
When task.updated is the first event available for an agent (its start row has aged out of retention), getOrCreate creates it with activationCount: 0, and the task.updated branch never initializes the count to 1 — unlike the task.progress and task.completed branches. The agent appears in the roster with zero activations even while running or terminal, and a subsequent task.started event would incorrectly be treated as the first activation, setting activationCount back to 1. Consider initializing activationCount to 1 in the task.updated branch when the agent is newly created.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/client-runtime/src/state/subagentRuntime.ts around line 521:
When `task.updated` is the first event available for an agent (its start row has aged out of retention), `getOrCreate` creates it with `activationCount: 0`, and the `task.updated` branch never initializes the count to 1 — unlike the `task.progress` and `task.completed` branches. The agent appears in the roster with zero activations even while running or terminal, and a subsequent `task.started` event would incorrectly be treated as the first activation, setting `activationCount` back to 1. Consider initializing `activationCount` to 1 in the `task.updated` branch when the agent is newly created.
| // guarantee). | ||
| const spawnRowIndex = new Map<string, number>(); | ||
| for (const entry of entries) { | ||
| const isTaskRow = |
There was a problem hiding this comment.
🟡 Medium src/session-logic.ts:859
collapseDerivedWorkLogEntries converts any task.progress/task.completed row that has a taskId into an agent-spawn CTA, unless isBackgroundTask is true. When taskType is absent (e.g. older persisted rows), isBackgroundTaskActivity returns false, so ordinary shell/monitor/plan task rows get misclassified as subagent spawns and grouped with real agent rows in the timeline instead of remaining normal work-log entries. Consider gating the agent-spawn CTA path on a positive signal (e.g. isWorkflowCoordinator or a known subagent taskType) rather than treating "not background" as "is a subagent."
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/session-logic.ts around line 859:
`collapseDerivedWorkLogEntries` converts any `task.progress`/`task.completed` row that has a `taskId` into an agent-spawn CTA, unless `isBackgroundTask` is true. When `taskType` is absent (e.g. older persisted rows), `isBackgroundTaskActivity` returns false, so ordinary shell/monitor/plan task rows get misclassified as subagent spawns and grouped with real agent rows in the timeline instead of remaining normal work-log entries. Consider gating the agent-spawn CTA path on a positive signal (e.g. `isWorkflowCoordinator` or a known subagent `taskType`) rather than treating "not background" as "is a subagent."
Rerun workflows looked invisible: the launching turn settles in seconds
('Worked for 8.9s') and turn-folding collapsed all its work entries —
including the spawn CTA — while the fleet runs on in the background. CTA
rows are now exempt from turn folds and pinned outside the '+N tool calls'
overflow toggle, so a live run is always visible at its spawn point. Each
rerun gets its own CTA row (grouping keys on the coordinator id, which is
unique per run).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| (entry) => entry.agentSpawn !== undefined, | ||
| ); | ||
| const hiddenEntries = overflowCandidates.slice(0, -MAX_VISIBLE_WORK_LOG_ENTRIES); | ||
| const visibleEntries = [ |
There was a problem hiding this comment.
🟡 Medium chat/MessagesTimeline.logic.ts:508
The visibleGroupedEntries are split into pinnedSpawnEntries and overflowCandidates and then concatenated with spawn entries first, so a group like [normalWork, agentSpawn] renders as [agentSpawn, normalWork]. This reorders the timeline whenever an agent-spawn CTA is interleaved with ordinary work rows. Consider filtering hidden entries from the original order instead of repartitioning and concatenating.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/chat/MessagesTimeline.logic.ts around line 508:
The `visibleGroupedEntries` are split into `pinnedSpawnEntries` and `overflowCandidates` and then concatenated with spawn entries first, so a group like `[normalWork, agentSpawn]` renders as `[agentSpawn, normalWork]`. This reorders the timeline whenever an agent-spawn CTA is interleaved with ordinary work rows. Consider filtering hidden entries from the original order instead of repartitioning and concatenating.
Live-test finding: statuses drifted (waiting/stalled agents alarming or reading wrong) while fleets ran. Adopts the monitoring-pill rule from the PR-monitoring design: one steady in-flight presentation. - Panel and CTA: pending/running/waiting all render as Working (sky, no amber); detail stays in the activity sub-line; footer shows one working count. Only settled states differentiate (completed/failed/stopped). - Fold: when a workflow coordinator settles, members that never received their own terminal row cascade to the coordinator's outcome (completed, or interrupted on failure) instead of reading as working forever. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live-test: the CTA only appeared after the workflow finished. Two causes, reproduced against the exact persisted thread data: - workEntryIndicatesToolNeutralStatus swallowed spawn rows mid-run (they derive from task.progress, tone 'thinking' = neutral) — visible only once a terminal row flipped them to success. CTA rows are now exempt. - collapse-by-group let the newest progress tick's id/createdAt/turnId win, drifting the row to the bottom of the timeline mid-run. The group anchor (spawn point) is now pinned. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live-test: a thread running a 9-agent workflow and a thread babysitting a PR both showed no sidebar status once their turns settled. The pill only reflected turn/session state. - Server: in-memory per-thread background liveness registry fed by the task lifecycle ingestion already processes (no persistence, no migration); exposed additively on the thread shell as backgroundLiveness: working | monitoring | null. - Vocabulary (per Theo): two states only. Agent/workflow fleets present as plain Working; Monitoring is reserved for watch loops (monitor tasks and turn-outliving background shells — PR babysitting, log tails) when they are the only live work. - Web: pill resolver and SidebarV2 status honor backgroundLiveness with the same recede treatment as Working (inbox-zero); Monitoring gets a steady label, no shimmer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Effect service conventions review: one clear violation (new shared mutable state held in a module global and consumed by two Effect services) plus one file-layout nit. Everything else in the changed Effect code (namespace imports from effect/* subpaths, per-session Map state inside Effect.gen, contract schemas) looks conformant.
Posted via Macroscope — Effect Service Conventions
Live-test: plain Task-tool subagents vanished from the Agents panel and CTA. The SDK reports them as taskType 'local_agent', which the agent-type allowlist didn't anticipate — real agents were silently classified as background work. Classification is now a denylist (shell/local_bash/ monitor/plan are background; anything else, including future agent-flavored type names, is an agent). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First live Codex probe (5 collab agents on gpt-5.6-sol) surfaced three gaps, fixed against the persisted wire data: - Children registered only via subAgentActivity (no thread/started with a spawn source), so no task.started ever formed and rows carried bare thread-id titles. subAgentActivity 'started' now synthesizes the task.started with agentPath-leaf naming. - Codex child rows are ALL timelineBypass, so the quiet-timeline filter suppressed every row before the CTA could form — a Codex fleet had no chat presence at all. Bypassed agent lifecycle rows now feed the CTA collapse (still max one row per batch); non-agent bypassed rows stay suppressed. - Idle now counts as not-live in the sidebar registry: an all-idle (resting, resumable) fleet no longer pins the thread at Working. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live-test round 2 on Codex direct spawns: - Idle (resting, resumable) agents wore a sky dot and sat in the live section, reading as stuck in-progress after the work finished. Idle now renders muted and sorts with settled. - Progress rows carried the bare child thread id as title, clobbering the real name (math_one → UUID) from task.started. The runtime now stamps the registered child's identity (nickname/role/agentPath) on every synthetic collabAgent event, the adapter only emits title when it has a real name, and subAgentActivity registration derives a nickname from the agentPath leaf. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live probe on 10 parallel collab agents: the wire emits subAgentActivity
{agentPath: '/root', kind: interacted} about the ROOT thread during collab
runs. Registration adopted the root as a child, so every subsequent root
notification — including the final assistant message and turn/completed —
was intercepted into the agent panel instead of the chat. The parent
looked hung ('Working 5m') after all subagents finished, with its report
riding an 'assistant message' row on the root's panel entry.
Registration now refuses the session's root thread (by id and by /root
path), and interception has a belt-and-braces root check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… row Claude background subagents settle between turns, so their completion rows arrive under later synthetic turn ids (or none). Batch keying by each row's own turn splintered ten parallel spawns into a stream of 'Kicked off N subagents' rows (live thread 7ac7ef05: completions spread across 8 different turn ids). Membership is now decided once at the first row seen per taskId — task.started rows (which carry the true spawn turn) seed the batch and collapse into its CTA instead of being skipped. Repro test derives exactly one CTA with all 10 agents from the persisted thread export. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nscript
Two live-test findings with parallel Claude subagents, one root cause:
even with forwardSubagentText off, the SDK forwards subagent-owned
messages tagged parent_tool_use_id, and the adapter emitted them as
parent conversation.
- Subagent assistant snapshots became interleaved leak messages ('sleep
ran successfully…') AND spawned a synthetic turn per completion — which
is also why the Working timer kept resetting to 0: each synthetic turn
restarted the elapsed clock. Subagent-owned assistant messages now only
advance the resume cursor.
- Subagent-owned text/thinking stream blocks wrote into the parent
transcript; they are now dropped (tool_use blocks still flow, with
agentId attribution, for the quiet-timeline re-homing).
Subagent results still reach the UI through the task.* lifecycle
(task_notification summaries) — the panel loses nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stop button now kills the fleet, not just the parent turn — the moment users need it most is a runaway spawn: - Claude: interruptTurn stops every non-terminal tracked task via the SDK's Query.stopTask (best-effort per task, capped concurrency) before interrupting the turn. Live-task set maintained from the task lifecycle. - Codex: children are threads with their own turns; interruptTurn now turn/interrupts each live child turn (tracked from intercepted child turn/started/completed) before the parent turn. Agents panel phases are collapsible per the Claude Code background-tasks pattern: live phases open by default, done phases collapse to header + member dot row; user toggles stick. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI: - lint: namespace node:fs/promises import; workflowScriptQuery tests use @effect/vitest it.effect instead of Effect.runPromise - ProjectionSnapshotQuery test expects the new backgroundLiveness field Review findings (verified against source, all real): - Critical: TOCTOU in workflow script read — open first, then verify the opened inode matches a fresh lstat of the resolved path; short reads honored via bytesRead (trailing-NUL fix folded in) - High: stopTask invoked through context.query so SDK 'this' binding survives; previously every stop silently failed via Effect.ignore - Medium: subagent stream filter now drops only text/thinking deltas, so attributed tool items keep their input_json_delta frames - Medium: liveness terminal rows clear BOTH buckets (terminal ticks often omit taskType, stranding monitor entries in the agents bucket) - Medium: nested agents (agentId + agent taskType) stay in the roster; only agent-owned shells are background. monitor_mcp/dream added to the non-agent denylist - Conventions: liveness registry is now ThreadBackgroundLivenessService (Context.Service + Layer, per-instance state, memoized shared Live provided at both consumers); OrchestrationGetWorkflowScriptError carries cause like sibling RPC errors, and readWorkflowScript forwards causes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Effect service conventions: two findings on the new ThreadBackgroundLiveness service. The rest of the changed Effect code (namespace imports, Effect.fn usage, yield* ThreadBackgroundLivenessService acquisition inside make) looks consistent with the conventions.
Posted via Macroscope — Effect Service Conventions
- workflowScriptQuery: nodeBuiltinImport suppression header (raw fs access is deliberate: containment needs realpath/inode identity, and the file lives outside any workspace root the FileSystem service is scoped to) - ClaudeAdapter: DateTime.makeUnsafe instead of new Date() for the task_updated end_time conversion - stop-everything test: wait on the task.* event stream instead of a wall-clock setTimeout (which also hung under the test clock) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…layer requirement Consolidates the split Services/+Layers/ files into one canonical module (tag + make + layer), counts nested agents toward liveness, and removes the per-consumer Layer.provide self-provides so ThreadBackgroundLivenessService shows up as a requirement in layer types. A single shared instance is provideMerged at OrchestrationInfrastructureLayerLive and at each test composition root. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Two Effect service convention issues in the new modules; details inline.
Posted via Macroscope — Effect Service Conventions
…ion fix - ThreadBackgroundLiveness now exports canonical make/layer consumed via namespace import, and references the inferred interface as ThreadBackgroundLivenessService["Service"]. - recordTaskLiveness drops any prior entry for a taskId on every path, so a task reclassified across transitions (untyped -> shell, or newly inert/agent-owned) moves buckets instead of leaving a stale duplicate pinning the thread's status. Regression test added. - workflowScriptQuery constructs OrchestrationGetWorkflowScriptError inline at each failure boundary instead of via a fail() wrapper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
One finding on the new orchestration RPC error's shape. The service module hoist (orchestration/ThreadBackgroundLiveness.ts with inline interface, make, layer, Service["Service"], requirement kept on the consuming layers) and the workflowScriptQuery error/cause cleanup from earlier rounds all look right.
Posted via Macroscope — Effect Service Conventions
…iptError Replaces the unstructured message field with a reason discriminator plus the offending scriptPath; message is derived from reason so wire shape stays serializable and failure kind is matchable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When a turn settles but native background work stays live (subagent fleets, workflows, monitor loops), the composer stop button is gone and nothing on the thread offers a way to stop it. Adds a composer banner -- 'N agents working in the background' / 'Monitoring in the background' -- driven by the shell's backgroundLiveness, with a Stop button wired to the existing stop-everything interrupt (session-scoped, kills all live tasks, no active turn required). Stopping state holds until liveness clears. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…anel TaskAgentLinkage gains an optional effort field alongside model. The Claude adapter seeds both at task_started (Agent tool input overrides, falling back to the session's model/effort selection) and refines model from the subagent's own assistant snapshots (authoritative API id); both repeat on every task.* row via the linkage bundle, so they persist in activity payloads and survive retention. The fold carries them into RuntimeSubagent and the Agents panel renders a compact 'sonnet-5[1m] · high' chip in the stats line. Codex children don't expose per-child model on the wire yet; the field stays absent there until the app-server surfaces it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ching, mobile terminal signal - Legacy task rows (no taskType, no linkage markers) keep pre-upgrade work-log behavior instead of reclassifying as subagents; membership is sticky per taskId so marker-less terminal rows still reach their agent. - Session death derives interruption: foldSubagentActivities takes sessionLive and marks orphaned live agents interrupted (idle/settled preserved), matching the server registry clearing on session.exited. - Codex children stamp their parent's spawn turn on every synthetic collabAgent/* event, and bypassed task.started rows may seed CTAs, so separate fleets anchor at their own spawn points instead of merging into one 'direct:no-turn' batch. - A late task.started after a terminal task.updated no longer reopens the run (guard on status, not activationCount). - Workflow retries count once per attempt (bump only in applyStatus). - Duplicate completions are fully idempotent (first result wins); provider endedAt wins over ingestion time on the settling transition. - CTA and settled-workflow token totals follow the panel-footer rule: coordinator usage only counts when no member rows exist. - Members with unknown phase indices render under unphasedMembers instead of vanishing; idle members keep their phase active. - Mobile keeps terminal bypassed task.updated rows (idle/failed/ interrupted) — the only terminal signal Codex children emit. - Stop-banner 'Stopping...' state resets on thread switch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ollapse - A task.completed arriving after a terminal task.updated (common Claude ordering) now enriches the settled agent — fills missing result/error and max-merges final usage — while status and timestamps stay frozen (duplicates still cannot replace the first result). - Codex status patches repeat the child identity bundle (role, known name, agentPath) so rows are self-describing when the start row ages out of retention, and mobile terminal labels get real names. - Mobile task-row identity extraction and collapse include task.updated, so reused Codex children fold into one row per child instead of stacking anonymous 'Task idle' rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| } | ||
| return []; | ||
| } | ||
| case "collabAgent/tokenUsage": { |
There was a problem hiding this comment.
🟡 Medium Layers/CodexAdapter.ts:669
The collabAgent/tokenUsage mapping emits task.progress using base from runtimeEventBase(event, ...), which only populates turnId when event.turnId is present. The synthetic token-usage event is the one collab event emitted without the child's spawnTurnId, so the emitted task.progress event carries turnId: null. When ingestion collapses by taskId, a null turnId overwrites the merged row's existing turnId; if token usage is the latest patch, the agent row loses its parent-turn linkage and is grouped in the wrong timeline location. Preserve the child's turnId (e.g., from payload) and include it in base before emitting this event.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/provider/Layers/CodexAdapter.ts around line 669:
The `collabAgent/tokenUsage` mapping emits `task.progress` using `base` from `runtimeEventBase(event, ...)`, which only populates `turnId` when `event.turnId` is present. The synthetic token-usage event is the one collab event emitted without the child's `spawnTurnId`, so the emitted `task.progress` event carries `turnId: null`. When ingestion collapses by `taskId`, a null `turnId` overwrites the merged row's existing `turnId`; if token usage is the latest patch, the agent row loses its parent-turn linkage and is grouped in the wrong timeline location. Preserve the child's `turnId` (e.g., from `payload`) and include it in `base` before emitting this event.
There was a problem hiding this comment.
Actionable comments posted: 10
🧹 Nitpick comments (10)
apps/server/src/orchestration/workflowScriptQuery.ts (1)
25-27: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winRecord the provider decision for the hardcoded Claude scripts root.
scriptsRoot()returns~/.claude/projects. Workflow scripts produced by other adapters resolve outside that root and fail withoutside-root. This PR also adds Codex workflow observability, so the Agents "{} script" affordance will not work for Codex runs.State the intended behavior for Codex, Cursor, Grok, and OpenCode. If only Claude persists workflow scripts today, document that in the module header so the limitation is explicit.
As per coding guidelines: "Provider-shaped features require a deliberate decision for each provider adapter: Codex, Claude, Cursor, Grok, and OpenCode."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/server/src/orchestration/workflowScriptQuery.ts` around lines 25 - 27, Document the provider decision in the module header for the hardcoded scriptsRoot() path: explicitly state the intended workflow-script behavior for Codex, Claude, Cursor, Grok, and OpenCode, including that only Claude currently persists scripts if that is the supported behavior. Ensure the documentation makes clear which providers support the Agents “script” affordance and avoids implying that all adapters use ~/.claude/projects.Source: Coding guidelines
apps/server/src/orchestration/workflowScriptQuery.test.ts (1)
10-15: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDo not write test fixtures into the real home directory.
Lines 10-13 create files inside the developer's or CI runner's actual
~/.claude/projects, which is the same directory the Claude harness uses. Line 14 uses a fixed name inos.tmpdir(). Two concurrent runs collide, andafterAlldeletes state under the real home directory.Make the scripts root injectable in
workflowScriptQuery.ts(for example an optional root parameter that defaults toscriptsRoot()), then point the test at amkdtempSyncdirectory. This also removes the need for theafterAllcleanup of real user state.Also applies to: 23-26
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/server/src/orchestration/workflowScriptQuery.test.ts` around lines 10 - 15, The test currently writes fixtures into the real home directory and uses a colliding fixed temporary filename. Make the scripts root injectable in the relevant workflow script query API, defaulting to scriptsRoot(), then have the test create an isolated mkdtempSync directory and place both fixtures under it. Update calls and cleanup to use only this temporary directory, removing afterAll cleanup of real user state.apps/web/src/rightPanelStore.ts (1)
52-52: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider keeping the storage version at 7.
The change adds one surface kind.
migratePersistedRightPanelStatealready tolerates the previous shape, because it only rewritesfileandterminalsurfaces and passes other kinds through. Raising the version to 8 discards every user's open right-panel tabs on upgrade without a correctness benefit.If you keep version 8, that is a deliberate reset. If not, revert to 7.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/rightPanelStore.ts` at line 52, Revert RIGHT_PANEL_STORAGE_VERSION to 7 because migratePersistedRightPanelState already preserves the newly added surface kind and no reset is required.apps/web/src/components/chat/MessagesTimeline.logic.ts (1)
531-533: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valueCompute
onlyToolEntriesfrom the hidden entries.The toggle describes the hidden rows.
visibleGroupedEntriesincludes the pinned spawn entries, which are never hidden. A spawn entry that is not tool-like flipsonlyToolEntriesto false even when every hidden row is a tool call, which changes the toggle label.♻️ Proposed change
- onlyToolEntries: visibleGroupedEntries.every((entry) => - workLogEntryIsToolLike(entry), - ), + onlyToolEntries: hiddenEntries.every((entry) => workLogEntryIsToolLike(entry)),🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/components/chat/MessagesTimeline.logic.ts` around lines 531 - 533, The onlyToolEntries calculation currently uses visibleGroupedEntries, which includes pinned spawn entries; update the logic around onlyToolEntries to evaluate the hidden entries collection instead, while preserving the existing workLogEntryIsToolLike predicate and toggle behavior.apps/web/src/session-logic.ts (2)
652-672: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueMove the quiet-timeline doc block onto
deriveWorkLogEntries.The comment at Lines 652-661 documents the work-log filtering contract of
deriveWorkLogEntries. It now sits directly aboveisAgentTaskStartedActivity, which carries its own doc comment on Line 662. A reader (and any doc tooling) attaches the block to the wrong function.Move the block to Line 712, immediately above
deriveWorkLogEntries.As per coding guidelines: "Use comments mainly to describe how a function is used; avoid annotating every line of behavior, and move comments when code moves."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/session-logic.ts` around lines 652 - 672, Move the quiet-timeline guarantee doc block from above isAgentTaskStartedActivity to immediately above deriveWorkLogEntries, preserving its text unchanged; keep the separate isAgentTaskStartedActivity documentation directly above that function.Source: Coding guidelines
882-895: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winCarry
parentAgentIdinto the derived entry and key by it.
toDerivedWorkLogEntrystorestaskIdandagentSpawn.workflowId, but notparentAgentId. IfparentAgentIdexists without a legacy:wf:encoding,agentSpawnGroupKeyfalls through todirect:<turnId>and splits the same workflow members into separate chat CTA rows from what the Agents panel groups under one coordinator.Also update the
:wf:-slot check only after copying the parent link so this only affects unencoded workflow members.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/session-logic.ts` around lines 882 - 895, Update DerivedWorkLogEntry and toDerivedWorkLogEntry to preserve parentAgentId, then update agentSpawnGroupKey to use the copied parentAgentId for workflow grouping when no legacy :wf: taskId encoding exists. Keep the existing :wf: parsing precedence unchanged, and apply the parent-link fallback before the coordinator and direct turnId cases.apps/server/src/provider/Layers/ClaudeAdapter.test.ts (1)
1453-1601: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd focused tests for
task_updatedand workflow member synthesis.The two new tests cover interrupt fan-out and model/effort inheritance. Two behaviors with more branching remain untested in this file:
task_updated: theCLAUDE_TASK_PATCH_STATUSmapping (killed→cancelled,paused→idle), removal fromliveTaskIdson a terminal patch, and theendedAtconversion.emitWorkflowMemberProgress: the<coordinatorTaskId>:wf:<index>member id, per-index dedupe, andworkflowAgentStatus.Both are backend behavior changes, so the guidelines require focused tests. Do you want me to draft them in the same harness style?
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/server/src/provider/Layers/ClaudeAdapter.test.ts` around lines 1453 - 1601, Add focused harness-style tests in ClaudeAdapter.test.ts for task_updated and workflow member synthesis. Cover killed→cancelled and paused→idle status mapping, terminal patch removal from liveTaskIds, endedAt conversion, and emitWorkflowMemberProgress behavior including <coordinatorTaskId>:wf:<index> IDs, per-index deduplication, and workflowAgentStatus.Source: Coding guidelines
apps/server/src/orchestration/ActivityPayloadProjection.test.ts (1)
5-15: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winSet the activity
kindper test so the task-lifecycle case is really exercised.The helper hardcodes
kind: "tool.completed". The second test is named for task lifecycle payloads, but it still passes a tool activity. IfprojectActivityPayloadbranches onkind, this test never reaches the task branch and the regression tripwire described in the file comment does not fire.💚 Proposed change
-function activity(payload: Record<string, unknown>): OrchestrationThreadActivity { +function activity( + payload: Record<string, unknown>, + kind = "tool.completed", +): OrchestrationThreadActivity { return { id: "activity-1", tone: "tool", - kind: "tool.completed", + kind, summary: "Tool", payload, turnId: null, createdAt: "2026-08-01T10:00:00.000Z", } as unknown as OrchestrationThreadActivity; }- const source = activity({ - taskId: "task-9", + const source = activity( + { + taskId: "task-9", ... - timelineBypass: true, - }); + timelineBypass: true, + }, + "task.started", + );Also applies to: 47-62
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/server/src/orchestration/ActivityPayloadProjection.test.ts` around lines 5 - 15, The activity helper currently hardcodes the tool kind, preventing task-lifecycle tests from exercising the task branch. Update activity and its callers so each test supplies the appropriate activity kind, preserving tool.completed for tool tests and using the task lifecycle kind in the second test.apps/server/src/provider/Layers/ClaudeAdapter.ts (1)
4358-4375: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winLog
stopTaskfailures instead of discarding them.Every
stopTaskrejection is swallowed twice:catch: () => undefinedandEffect.ignore. A provider that refuses to stop background tasks then produces no signal at all, and users see agents keep burning tokens after pressing Stop. Keep the best-effort semantics, but record the failure.♻️ Proposed change to keep failures observable
- yield* Effect.forEach( - liveIds, - (taskId) => - Effect.tryPromise({ - // Invoke through the query object: SDK methods rely on `this`. - try: () => context.query.stopTask!(taskId), - catch: () => undefined, - }).pipe(Effect.ignore), - { concurrency: 8, discard: true }, - ); + yield* Effect.forEach( + liveIds, + (taskId) => + Effect.tryPromise({ + // Invoke through the query object: SDK methods rely on `this`. + try: () => context.query.stopTask!(taskId), + catch: (cause) => cause, + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("claude.task.stop-failed", { threadId, taskId, cause }), + ), + ), + { concurrency: 8, discard: true }, + );🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/server/src/provider/Layers/ClaudeAdapter.ts` around lines 4358 - 4375, Update the stop-task cleanup around context.query.stopTask to preserve best-effort per-task execution while logging each rejection or thrown failure with the taskId and error details. Replace the silent catch/ignore handling without allowing one failed stopTask call to prevent remaining tasks from being attempted or the subsequent interrupt from running.apps/server/src/provider/Layers/CodexAdapter.ts (1)
760-766: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winRecord the intentional omission for Cursor, Grok, and OpenCode child task events.
This PR adds
task.*child events for Codex and Claude only, while Cursor, Grok, and OpenCode do not emitcollabAgent/...*child events. Capture that decision in the PR description or a code comment so the provider-level behavior is explicit.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/server/src/provider/Layers/CodexAdapter.ts` around lines 760 - 766, Document in a nearby comment to mapToRuntimeEvents that Cursor, Grok, and OpenCode intentionally do not map collabAgent/... child task events because those providers do not emit them; clarify that task.* child events are currently supported only for Codex and Claude.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/server/src/orchestration/workflowScriptQuery.test.ts`:
- Around line 16-21: Update the symlink setup and assertions in the workflow
script query test around NodeFS.symlinkSync and readWorkflowScript: only
tolerate an already-existing link, rethrow unexpected or permission errors,
verify the link exists before querying, and assert that the returned Failure
reason is the containment-specific value rather than checking only _tag.
In `@apps/server/src/orchestration/workflowScriptQuery.ts`:
- Around line 29-38: Update readWorkflowScript and getWorkflowScript to carry
the caller’s threadId, then resolve the workflow script location recorded for
that thread in runHandles.scriptPath before accepting the requested path hint.
Require the resolved requested path to match the thread’s recorded script
location, while retaining ~/.claude/projects as a secondary containment check.
Reject mismatches through the existing invalid-path error flow.
In `@apps/server/src/provider/Layers/CodexAdapter.ts`:
- Around line 669-700: Update the token usage parsing in the
collabAgent/tokenUsage case to define and reuse the existing count validator
before validating totalTokens. Require totalTokens to pass count, return [] when
it is undefined, and assign the validated value to RuntimeTaskUsage; reuse local
validated values for the other fields instead of calling count repeatedly.
In `@apps/server/src/provider/Layers/CodexSessionRuntime.ts`:
- Around line 1109-1122: The interceptCollabChildNotification switch currently
drops child error notifications, leaving the child marked running. Add a case
for "error" that emits a collabAgent/statusChanged event with type "systemError"
and the child identity/status payload required by mapCollabAgentEvent to produce
a failed task update; preserve existing handling for closed and other
notifications.
In `@apps/web/src/components/chat/MessagesTimeline.logic.ts`:
- Around line 504-512: Update the work-group entry selection around
pinnedSpawnEntries, hiddenEntries, visibleEntries, and renderedEntries to
preserve the original visibleGroupedEntries chronological order. Select retained
entries by their indices rather than concatenating all spawn rows with the
overflow tail, and ensure the expanded path renders the full ordered group while
the collapsed path renders only the retained ordered entries.
In `@apps/web/src/components/ChatView.tsx`:
- Around line 4209-4224: Update handleStopBackgroundWork so every
interruptThreadTurn failure resets isStoppingBackgroundWork, including when
isAtomCommandInterrupted(result) is true, while preserving the existing
error-setting path only for non-interrupted failures.
In `@apps/web/src/components/Sidebar.logic.ts`:
- Around line 443-450: Reorder the backgroundLiveness branches in both status
resolvers in apps/web/src/components/Sidebar.logic.ts:443-450 and
apps/web/src/components/Sidebar.logic.ts:626-646. In the first resolver, keep
session?.status === "error" ahead of background statuses; in the second, keep
hasPlanReadyPrompt ahead of the background-liveness pill branches so error and
actionable states take precedence.
- Line 135: Update the status priority mapping around Monitoring so Monitoring
ranks below the active Working and Connecting statuses, or adjust
resolveProjectStatusIndicator to break equal-priority ties in favor of active
work. Preserve the existing project-group status resolution behavior for all
other statuses.
In `@apps/web/src/session-logic.ts`:
- Around line 674-710: Update isAgentInternalActivity so agentId ownership alone
does not classify nested agent task rows as internal; use
isBackgroundTaskActivity() to distinguish shell-only background tasks from agent
task activity. Preserve nested agent rows and their CTA anchors while keeping
work-log, Agentic Activities, and spawn CTA classification consistent with the
shared task-type contract.
In `@packages/client-runtime/src/state/subagentRuntime.ts`:
- Around line 469-473: Update the status resolution in the flow using
TASK_COMPLETED_STATUS to validate that the looked-up value is a valid
RuntimeSubagentStatus before passing it to applyStatus. Guard against inherited
keys such as “toString” and “constructor” so invalid payload statuses fall back
to “completed,” rather than relying on truthiness or nullish coalescing alone.
---
Nitpick comments:
In `@apps/server/src/orchestration/ActivityPayloadProjection.test.ts`:
- Around line 5-15: The activity helper currently hardcodes the tool kind,
preventing task-lifecycle tests from exercising the task branch. Update activity
and its callers so each test supplies the appropriate activity kind, preserving
tool.completed for tool tests and using the task lifecycle kind in the second
test.
In `@apps/server/src/orchestration/workflowScriptQuery.test.ts`:
- Around line 10-15: The test currently writes fixtures into the real home
directory and uses a colliding fixed temporary filename. Make the scripts root
injectable in the relevant workflow script query API, defaulting to
scriptsRoot(), then have the test create an isolated mkdtempSync directory and
place both fixtures under it. Update calls and cleanup to use only this
temporary directory, removing afterAll cleanup of real user state.
In `@apps/server/src/orchestration/workflowScriptQuery.ts`:
- Around line 25-27: Document the provider decision in the module header for the
hardcoded scriptsRoot() path: explicitly state the intended workflow-script
behavior for Codex, Claude, Cursor, Grok, and OpenCode, including that only
Claude currently persists scripts if that is the supported behavior. Ensure the
documentation makes clear which providers support the Agents “script” affordance
and avoids implying that all adapters use ~/.claude/projects.
In `@apps/server/src/provider/Layers/ClaudeAdapter.test.ts`:
- Around line 1453-1601: Add focused harness-style tests in
ClaudeAdapter.test.ts for task_updated and workflow member synthesis. Cover
killed→cancelled and paused→idle status mapping, terminal patch removal from
liveTaskIds, endedAt conversion, and emitWorkflowMemberProgress behavior
including <coordinatorTaskId>:wf:<index> IDs, per-index deduplication, and
workflowAgentStatus.
In `@apps/server/src/provider/Layers/ClaudeAdapter.ts`:
- Around line 4358-4375: Update the stop-task cleanup around
context.query.stopTask to preserve best-effort per-task execution while logging
each rejection or thrown failure with the taskId and error details. Replace the
silent catch/ignore handling without allowing one failed stopTask call to
prevent remaining tasks from being attempted or the subsequent interrupt from
running.
In `@apps/server/src/provider/Layers/CodexAdapter.ts`:
- Around line 760-766: Document in a nearby comment to mapToRuntimeEvents that
Cursor, Grok, and OpenCode intentionally do not map collabAgent/... child task
events because those providers do not emit them; clarify that task.* child
events are currently supported only for Codex and Claude.
In `@apps/web/src/components/chat/MessagesTimeline.logic.ts`:
- Around line 531-533: The onlyToolEntries calculation currently uses
visibleGroupedEntries, which includes pinned spawn entries; update the logic
around onlyToolEntries to evaluate the hidden entries collection instead, while
preserving the existing workLogEntryIsToolLike predicate and toggle behavior.
In `@apps/web/src/rightPanelStore.ts`:
- Line 52: Revert RIGHT_PANEL_STORAGE_VERSION to 7 because
migratePersistedRightPanelState already preserves the newly added surface kind
and no reset is required.
In `@apps/web/src/session-logic.ts`:
- Around line 652-672: Move the quiet-timeline guarantee doc block from above
isAgentTaskStartedActivity to immediately above deriveWorkLogEntries, preserving
its text unchanged; keep the separate isAgentTaskStartedActivity documentation
directly above that function.
- Around line 882-895: Update DerivedWorkLogEntry and toDerivedWorkLogEntry to
preserve parentAgentId, then update agentSpawnGroupKey to use the copied
parentAgentId for workflow grouping when no legacy :wf: taskId encoding exists.
Keep the existing :wf: parsing precedence unchanged, and apply the parent-link
fallback before the coordinator and direct turnId cases.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 1ee12d61-2e8a-40e0-b88a-6979acf94b14
📒 Files selected for processing (39)
apps/mobile/src/lib/threadActivity.tsapps/server/integration/OrchestrationEngineHarness.integration.tsapps/server/src/auth/RpcAuthorization.tsapps/server/src/orchestration/ActivityPayloadProjection.test.tsapps/server/src/orchestration/Layers/CheckpointReactor.test.tsapps/server/src/orchestration/Layers/OrchestrationEngine.test.tsapps/server/src/orchestration/Layers/ProjectionPipeline.test.tsapps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.tsapps/server/src/orchestration/Layers/ProjectionSnapshotQuery.tsapps/server/src/orchestration/Layers/ProviderCommandReactor.test.tsapps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.tsapps/server/src/orchestration/Layers/ProviderRuntimeIngestion.tsapps/server/src/orchestration/ThreadBackgroundLiveness.test.tsapps/server/src/orchestration/ThreadBackgroundLiveness.tsapps/server/src/orchestration/runtimeLayer.tsapps/server/src/orchestration/workflowScriptQuery.test.tsapps/server/src/orchestration/workflowScriptQuery.tsapps/server/src/provider/Layers/ClaudeAdapter.test.tsapps/server/src/provider/Layers/ClaudeAdapter.tsapps/server/src/provider/Layers/CodexAdapter.tsapps/server/src/provider/Layers/CodexSessionRuntime.tsapps/server/src/ws.tsapps/web/src/components/AgentsPanel.tsxapps/web/src/components/ChatView.tsxapps/web/src/components/RightPanelTabs.tsxapps/web/src/components/Sidebar.logic.tsapps/web/src/components/SidebarV2.tsxapps/web/src/components/chat/MessagesTimeline.logic.tsapps/web/src/components/chat/MessagesTimeline.tsxapps/web/src/rightPanelStore.tsapps/web/src/session-logic.test.tsapps/web/src/session-logic.tspackages/client-runtime/package.jsonpackages/client-runtime/src/state/orchestration.tspackages/client-runtime/src/state/subagentRuntime.test.tspackages/client-runtime/src/state/subagentRuntime.tspackages/contracts/src/orchestration.tspackages/contracts/src/providerRuntime.tspackages/contracts/src/rpc.ts
| const link = NodePath.join(root, "sneaky.js"); | ||
| try { | ||
| NodeFS.symlinkSync(outside, link); | ||
| } catch { | ||
| // pre-existing from a prior run | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
The symlink escape test can pass without testing containment.
The catch block at Lines 19-21 swallows every symlinkSync failure, including EPERM on Windows and any unexpected error. If the link does not exist, readWorkflowScript fails with reason: "not-found" and assert.equal(sneaky._tag, "Failure") still passes. The assertion then proves nothing about realpath re-containment.
Verify the link exists, and assert the failure reason instead of only _tag.
💚 Proposed fix to assert the containment reason
const link = NodePath.join(root, "sneaky.js");
-try {
- NodeFS.symlinkSync(outside, link);
-} catch {
- // pre-existing from a prior run
-}
+NodeFS.rmSync(link, { force: true });
+NodeFS.symlinkSync(outside, link); const escaped = yield* Effect.exit(readWorkflowScript({ scriptPath: outside }));
- assert.equal(escaped._tag, "Failure");
+ assert.equal(escaped._tag, "Failure");
+ assert.equal(Cause.squash(escaped.cause).reason, "outside-root");
// A symlink INSIDE the root pointing outside must also fail (realpath
// re-containment of the leaf).
const sneaky = yield* Effect.exit(readWorkflowScript({ scriptPath: link }));
- assert.equal(sneaky._tag, "Failure");
+ assert.equal(sneaky._tag, "Failure");
+ assert.equal(Cause.squash(sneaky.cause).reason, "outside-root");Also applies to: 48-57
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/server/src/orchestration/workflowScriptQuery.test.ts` around lines 16 -
21, Update the symlink setup and assertions in the workflow script query test
around NodeFS.symlinkSync and readWorkflowScript: only tolerate an
already-existing link, rethrow unexpected or permission errors, verify the link
exists before querying, and assert that the returned Failure reason is the
containment-specific value rather than checking only _tag.
| export const readWorkflowScript = Effect.fn("orchestration.readWorkflowScript")(function* (input: { | ||
| readonly scriptPath: string; | ||
| }) { | ||
| const requested = input.scriptPath; | ||
|
|
||
| if (!NodePath.isAbsolute(requested) || NodePath.extname(requested) !== ".js") { | ||
| return yield* Effect.fail( | ||
| new OrchestrationGetWorkflowScriptError({ reason: "invalid-path", scriptPath: requested }), | ||
| ); | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Find recorded workflow script paths in server state/contracts.
rg -n --type=ts -C4 'runHandles|scriptPath' apps/server/src packages/contracts/src | head -200Repository: pingdotgg/t3code
Length of output: 17052
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "## workflowScriptQuery.ts"
sed -n '1,130p' apps/server/src/orchestration/workflowScriptQuery.ts | cat -n
echo
echo "## ws.ts around readWorkflowScript call"
rg -n --type=ts -C8 'readWorkflowScript|OrchestrationGetWorkflowScriptInput|workflowScript|threadId' apps/server/src/ws.ts
echo
echo "## orchestration state/services that persist runHandles or thread workflows"
rg -n --type=ts -C3 'runHandles|scriptPath|Thread|Orchestration.*Workflow|workflow.*Path|projectDir|project' apps/server/src/orchestration apps/server/src | head -300Repository: pingdotgg/t3code
Length of output: 49100
Scope workflow script reads by thread before trusting the path hint.
readWorkflowScript(input: { scriptPath }) checks only the absolute .js path against ~/.claude/projects, so it does not enforce the contract comment that the path is a hint from runHandles.scriptPath. getWorkflowScript also passes only input.scriptPath, losing threadId. Add server-side scoping for the caller’s thread and compare the resolved script path against the recorded workflow script location; use ~/.claude/projects only as a secondary bound.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/server/src/orchestration/workflowScriptQuery.ts` around lines 29 - 38,
Update readWorkflowScript and getWorkflowScript to carry the caller’s threadId,
then resolve the workflow script location recorded for that thread in
runHandles.scriptPath before accepting the requested path hint. Require the
resolved requested path to match the thread’s recorded script location, while
retaining ~/.claude/projects as a secondary containment check. Reject mismatches
through the existing invalid-path error flow.
| case "collabAgent/tokenUsage": { | ||
| // Cumulative per child thread: always the `total` breakdown, never | ||
| // `last` (which shrinks on follow-ups). Client folds max-merge. | ||
| const tokenUsage = | ||
| typeof payload.tokenUsage === "object" && payload.tokenUsage !== null | ||
| ? (payload.tokenUsage as Record<string, unknown>) | ||
| : undefined; | ||
| const total = | ||
| typeof tokenUsage?.total === "object" && tokenUsage.total !== null | ||
| ? (tokenUsage.total as Record<string, unknown>) | ||
| : undefined; | ||
| const totalTokens = typeof total?.totalTokens === "number" ? total.totalTokens : undefined; | ||
| if (totalTokens === undefined) { | ||
| return []; | ||
| } | ||
| const count = (value: unknown): number | undefined => | ||
| typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; | ||
| const typedUsage: RuntimeTaskUsage = { | ||
| totalTokens, | ||
| ...(count(total?.inputTokens) !== undefined | ||
| ? { inputTokens: count(total?.inputTokens) } | ||
| : {}), | ||
| ...(count(total?.cachedInputTokens) !== undefined | ||
| ? { cachedInputTokens: count(total?.cachedInputTokens) } | ||
| : {}), | ||
| ...(count(total?.outputTokens) !== undefined | ||
| ? { outputTokens: count(total?.outputTokens) } | ||
| : {}), | ||
| ...(count(total?.reasoningOutputTokens) !== undefined | ||
| ? { reasoningOutputTokens: count(total?.reasoningOutputTokens) } | ||
| : {}), | ||
| }; |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Validate totalTokens with the same rule as the other counts.
Line 680 accepts any number, including NaN, Infinity, and negative values, while every other field passes through count. RuntimeTaskUsage.totalTokens is NonNegativeInt, so a malformed wire value produces a payload that violates the contract. Reuse count for totalTokens as well. The same change removes the duplicated count(...) calls per field.
🐛 Proposed fix
- const totalTokens = typeof total?.totalTokens === "number" ? total.totalTokens : undefined;
- if (totalTokens === undefined) {
- return [];
- }
const count = (value: unknown): number | undefined =>
typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
+ const totalTokens = count(total?.totalTokens);
+ if (totalTokens === undefined) {
+ return [];
+ }
+ const inputTokens = count(total?.inputTokens);
+ const cachedInputTokens = count(total?.cachedInputTokens);
+ const outputTokens = count(total?.outputTokens);
+ const reasoningOutputTokens = count(total?.reasoningOutputTokens);
const typedUsage: RuntimeTaskUsage = {
totalTokens,
- ...(count(total?.inputTokens) !== undefined
- ? { inputTokens: count(total?.inputTokens) }
- : {}),
- ...(count(total?.cachedInputTokens) !== undefined
- ? { cachedInputTokens: count(total?.cachedInputTokens) }
- : {}),
- ...(count(total?.outputTokens) !== undefined
- ? { outputTokens: count(total?.outputTokens) }
- : {}),
- ...(count(total?.reasoningOutputTokens) !== undefined
- ? { reasoningOutputTokens: count(total?.reasoningOutputTokens) }
- : {}),
+ ...(inputTokens !== undefined ? { inputTokens } : {}),
+ ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}),
+ ...(outputTokens !== undefined ? { outputTokens } : {}),
+ ...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}),
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| case "collabAgent/tokenUsage": { | |
| // Cumulative per child thread: always the `total` breakdown, never | |
| // `last` (which shrinks on follow-ups). Client folds max-merge. | |
| const tokenUsage = | |
| typeof payload.tokenUsage === "object" && payload.tokenUsage !== null | |
| ? (payload.tokenUsage as Record<string, unknown>) | |
| : undefined; | |
| const total = | |
| typeof tokenUsage?.total === "object" && tokenUsage.total !== null | |
| ? (tokenUsage.total as Record<string, unknown>) | |
| : undefined; | |
| const totalTokens = typeof total?.totalTokens === "number" ? total.totalTokens : undefined; | |
| if (totalTokens === undefined) { | |
| return []; | |
| } | |
| const count = (value: unknown): number | undefined => | |
| typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; | |
| const typedUsage: RuntimeTaskUsage = { | |
| totalTokens, | |
| ...(count(total?.inputTokens) !== undefined | |
| ? { inputTokens: count(total?.inputTokens) } | |
| : {}), | |
| ...(count(total?.cachedInputTokens) !== undefined | |
| ? { cachedInputTokens: count(total?.cachedInputTokens) } | |
| : {}), | |
| ...(count(total?.outputTokens) !== undefined | |
| ? { outputTokens: count(total?.outputTokens) } | |
| : {}), | |
| ...(count(total?.reasoningOutputTokens) !== undefined | |
| ? { reasoningOutputTokens: count(total?.reasoningOutputTokens) } | |
| : {}), | |
| }; | |
| case "collabAgent/tokenUsage": { | |
| // Cumulative per child thread: always the `total` breakdown, never | |
| // `last` (which shrinks on follow-ups). Client folds max-merge. | |
| const tokenUsage = | |
| typeof payload.tokenUsage === "object" && payload.tokenUsage !== null | |
| ? (payload.tokenUsage as Record<string, unknown>) | |
| : undefined; | |
| const total = | |
| typeof tokenUsage?.total === "object" && tokenUsage.total !== null | |
| ? (tokenUsage.total as Record<string, unknown>) | |
| : undefined; | |
| const count = (value: unknown): number | undefined => | |
| typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; | |
| const totalTokens = count(total?.totalTokens); | |
| if (totalTokens === undefined) { | |
| return []; | |
| } | |
| const inputTokens = count(total?.inputTokens); | |
| const cachedInputTokens = count(total?.cachedInputTokens); | |
| const outputTokens = count(total?.outputTokens); | |
| const reasoningOutputTokens = count(total?.reasoningOutputTokens); | |
| const typedUsage: RuntimeTaskUsage = { | |
| totalTokens, | |
| ...(inputTokens !== undefined ? { inputTokens } : {}), | |
| ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}), | |
| ...(outputTokens !== undefined ? { outputTokens } : {}), | |
| ...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}), | |
| }; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/server/src/provider/Layers/CodexAdapter.ts` around lines 669 - 700,
Update the token usage parsing in the collabAgent/tokenUsage case to define and
reuse the existing count validator before validating totalTokens. Require
totalTokens to pass count, return [] when it is undefined, and assign the
validated value to RuntimeTaskUsage; reuse local validated values for the other
fields instead of calling count repeatedly.
| case "thread/closed": | ||
| yield* emitEvent({ | ||
| kind: "notification", | ||
| threadId: options.threadId, | ||
| ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), | ||
| method: "collabAgent/closed", | ||
| payload: childIdentity, | ||
| }); | ||
| return true; | ||
| default: | ||
| // Remaining child chatter (name updates, deltas, plan updates) | ||
| // stays out of the parent timeline and has no agent mapping yet. | ||
| return true; | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Confirm whether `error` is a per-thread server notification that can target a child thread.
set -euo pipefail
rg -nP --type=ts -C4 'SERVER_NOTIFICATION_METHODS' apps/server/src/provider | head -n 60
rg -nP --type=ts -C3 '"error"' apps/server/src/provider/Layers/CodexSessionRuntime.tsRepository: pingdotgg/t3code
Length of output: 2829
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Map the relevant file and inspect the child notification handling plus Codex server notification registration.
ast-grep outline apps/server/src/provider/Layers/CodexSessionRuntime.ts --match registerServerNotification --view expanded || true
sed -n '1050,1140p' apps/server/src/provider/Layers/CodexSessionRuntime.ts
sed -n '1150,1445p' apps/server/src/provider/Layers/CodexSessionRuntime.ts
sed -n '70,530p' apps/server/src/provider/Layers/CodexSessionRuntime.ts
# Locate and inspect the Codex RPC protocol definitions around server notifications.
rg -n --type=ts 'SERVER_NOTIFICATION_METHODS|ServerNotificationMethod|type "error"|notification method|method: "error"' apps/server/src/providers apps/server/src/provider -C 4 | head -n 220Repository: pingdotgg/t3code
Length of output: 34193
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the child routing, raw notification handling, and notification emission/Agent mapping paths.
sed -n '600,1020p' apps/server/src/provider/Layers/CodexSessionRuntime.ts
sed -n '1020,1135p' apps/server/src/provider/Layers/CodexSessionRuntime.ts
sed -n '1135,1250p' apps/server/src/provider/Layers/CodexSessionRuntime.ts
# Locate generated protocol and tests around Codex error notifications.
rg -n --type=ts 'error.*notification|SERVER_NOTIFICATION_METHODS|ServerNotificationMethod|thread/status/changed|systemError' -C 3 | head -n 260
# Inspect CodexAdapter.test.ts around the existing raw error notification handling.
sed -n '710,770p' apps/server/src/provider/Layers/CodexAdapter.test.tsRepository: pingdotgg/t3code
Length of output: 25813
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect how provider events are transformed into task runtime events and error status.
rg -n --type=ts 'task.*event|ProviderTaskEvent|task.*(started|failed|complete|status|error)|notification.*collabAgent/statusChanged|collabAgent/statusChanged' -C 4 | sed -n '1,260p'
# Locate the task event reducer/event handling in adapters.
rg -n --type=ts 'case "notification"|notification.*kind|notification.kind|failed.*notification|systemError|statusChanged' apps/server/src/provider -C 4 | sed -n '1,260p'
# Inspect surrounding lines where no result would imply failure to locate the handling.
rg -n --type=ts 'case "notification"|notification\.kind|notification\\.kind|notification.kind' apps/server/src/provider -C 4 || trueRepository: pingdotgg/t3code
Length of output: 619
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the exact line counts and file contents without relying on line numbers from the previous sed output.
wc -l apps/server/src/provider/Layers/CodexSessionRuntime.ts
sed -n '1080,1128p' apps/server/src/provider/Layers/CodexSessionRuntime.ts
sed -n '690,705p' apps/server/src/provider/Layers/CodexSessionRuntime.ts
# Check generated protocol definitions for `error` notification shape.
rg -n 'method: "error"|type: "error"|params: .*\{\s*threadId|threadId.*params|notification.*error|ServerNotification' -C 3 $(git ls-files | rg '(effect-codex|CodexRpc|types|generated|protocol).*\.ts$') | sed -n '1,260p'
# Locate all statusChanged/task transition handling.
rg -n --type=ts 'statusChanged|systemError|status: "error"|task.*(fail|failed|complete|started|update|runtime)' apps/server/src/provider apps/server/src -C 3 | sed -n '1,320p'Repository: pingdotgg/t3code
Length of output: 50372
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect CodexProvider/CodexAdapter task-event conversion around statusChanged and error mapping.
sed -n '330,440p' apps/server/src/provider/Layers/CodexProvider.ts
sed -n '500,610p' apps/server/src/provider/Layers/CodexProvider.ts
sed -n '330,690p' apps/server/src/provider/Layers/CodexAdapter.ts
# Inspect generated error notification definition.
sed -n '1797,1895p' packages/effect-codex-app-server/src/_generated/schema.gen.ts
sed -n '409,425p' packages/effect-codex-app-server/src/_generated/meta.gen.ts
# Inspect existing tests around codex raw error notification and collab-agent status events if present.
rg -n --type=ts 'collabAgent/statusChanged|collabAgent.*statusChanged|systemError|shouldSuppressChildConversationNotification|interceptCollabChildNotification|error.*notification|NotificationMethod.*error' apps/server/src/provider/Layers/CodexAdapter.test.ts apps/server/src/provider/Layers/CodexProvider.test.ts -C 3 | sed -n '1,240p'Repository: pingdotgg/t3code
Length of output: 24093
Map child error notifications to a Codex failure signal.
interceptCollabChildNotification returns true for every unrecognized child notification, so a child error notification is dropped before mapCollabAgentEvent. This leaves the child in its prior running state because only collabAgent/statusChanged with type: "systemError" maps to task.updated / status: "failed".
Handle case "error" and emit a failed collabAgent/statusChanged, or at least log the dropped error.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/server/src/provider/Layers/CodexSessionRuntime.ts` around lines 1109 -
1122, The interceptCollabChildNotification switch currently drops child error
notifications, leaving the child marked running. Add a case for "error" that
emits a collabAgent/statusChanged event with type "systemError" and the child
identity/status payload required by mapCollabAgentEvent to produce a failed task
update; preserve existing handling for closed and other notifications.
| const pinnedSpawnEntries = visibleGroupedEntries.filter( | ||
| (entry) => entry.agentSpawn !== undefined, | ||
| ); | ||
| const hiddenEntries = overflowCandidates.slice(0, -MAX_VISIBLE_WORK_LOG_ENTRIES); | ||
| const visibleEntries = [ | ||
| ...pinnedSpawnEntries, | ||
| ...overflowCandidates.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES), | ||
| ]; | ||
| const renderedEntries = expanded ? [...hiddenEntries, ...visibleEntries] : visibleEntries; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Pinned spawn rows break the chronological order of the work group.
visibleEntries places every spawn entry before the retained tail entries, and renderedEntries places hiddenEntries before visibleEntries. A spawn row created in the middle of the group therefore renders above earlier tool rows, and expanding the group moves it again. Rows in this list are otherwise ordered by time.
Preserve group order and select the retained entries by index instead of concatenating two filtered lists.
♻️ Proposed order-preserving selection
- const overflowCandidates = visibleGroupedEntries.filter(
- (entry) => entry.agentSpawn === undefined,
- );
- const pinnedSpawnEntries = visibleGroupedEntries.filter(
- (entry) => entry.agentSpawn !== undefined,
- );
- const hiddenEntries = overflowCandidates.slice(0, -MAX_VISIBLE_WORK_LOG_ENTRIES);
- const visibleEntries = [
- ...pinnedSpawnEntries,
- ...overflowCandidates.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES),
- ];
+ const overflowCandidates = visibleGroupedEntries.filter(
+ (entry) => entry.agentSpawn === undefined,
+ );
+ const hiddenIds = new Set(
+ overflowCandidates.slice(0, -MAX_VISIBLE_WORK_LOG_ENTRIES).map((entry) => entry.id),
+ );
+ const hiddenEntries = visibleGroupedEntries.filter((entry) => hiddenIds.has(entry.id));
+ const visibleEntries = visibleGroupedEntries.filter((entry) => !hiddenIds.has(entry.id));With this shape, renderedEntries for the expanded case becomes visibleGroupedEntries:
- const renderedEntries = expanded ? [...hiddenEntries, ...visibleEntries] : visibleEntries;
+ const renderedEntries = expanded ? visibleGroupedEntries : visibleEntries;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const pinnedSpawnEntries = visibleGroupedEntries.filter( | |
| (entry) => entry.agentSpawn !== undefined, | |
| ); | |
| const hiddenEntries = overflowCandidates.slice(0, -MAX_VISIBLE_WORK_LOG_ENTRIES); | |
| const visibleEntries = [ | |
| ...pinnedSpawnEntries, | |
| ...overflowCandidates.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES), | |
| ]; | |
| const renderedEntries = expanded ? [...hiddenEntries, ...visibleEntries] : visibleEntries; | |
| const overflowCandidates = visibleGroupedEntries.filter( | |
| (entry) => entry.agentSpawn === undefined, | |
| ); | |
| const hiddenIds = new Set( | |
| overflowCandidates.slice(0, -MAX_VISIBLE_WORK_LOG_ENTRIES).map((entry) => entry.id), | |
| ); | |
| const hiddenEntries = visibleGroupedEntries.filter((entry) => hiddenIds.has(entry.id)); | |
| const visibleEntries = visibleGroupedEntries.filter((entry) => !hiddenIds.has(entry.id)); | |
| const renderedEntries = expanded ? visibleGroupedEntries : visibleEntries; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/components/chat/MessagesTimeline.logic.ts` around lines 504 -
512, Update the work-group entry selection around pinnedSpawnEntries,
hiddenEntries, visibleEntries, and renderedEntries to preserve the original
visibleGroupedEntries chronological order. Select retained entries by their
indices rather than concatenating all spawn rows with the overflow tail, and
ensure the expanded path renders the full ordered group while the collapsed path
renders only the retained ordered entries.
| const handleStopBackgroundWork = useCallback(async () => { | ||
| if (!activeThread) return; | ||
| setIsStoppingBackgroundWork(true); | ||
| const result = await interruptThreadTurn({ | ||
| environmentId, | ||
| input: buildThreadTurnInterruptInput(activeThread), | ||
| }); | ||
| if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | ||
| setIsStoppingBackgroundWork(false); | ||
| const error = squashAtomCommandFailure(result); | ||
| setThreadError( | ||
| activeThread.id, | ||
| error instanceof Error ? error.message : "Failed to stop background work.", | ||
| ); | ||
| } | ||
| }, [activeThread, environmentId, interruptThreadTurn, setThreadError]); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Clear isStoppingBackgroundWork when the interrupt command is interrupted.
The reset at line 4217 runs only when isAtomCommandInterrupted(result) is false. If the command itself is interrupted, no request reached the server, the liveness never clears, and the effect at line 4197 never fires. The Stop button then stays disabled and shows "Stopping..." for the rest of the thread session.
Reset the flag for every failure, and keep the error toast path unchanged.
🐛 Proposed fix
- if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
- setIsStoppingBackgroundWork(false);
- const error = squashAtomCommandFailure(result);
- setThreadError(
- activeThread.id,
- error instanceof Error ? error.message : "Failed to stop background work.",
- );
- }
+ if (result._tag === "Failure") {
+ setIsStoppingBackgroundWork(false);
+ if (!isAtomCommandInterrupted(result)) {
+ const error = squashAtomCommandFailure(result);
+ setThreadError(
+ activeThread.id,
+ error instanceof Error ? error.message : "Failed to stop background work.",
+ );
+ }
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const handleStopBackgroundWork = useCallback(async () => { | |
| if (!activeThread) return; | |
| setIsStoppingBackgroundWork(true); | |
| const result = await interruptThreadTurn({ | |
| environmentId, | |
| input: buildThreadTurnInterruptInput(activeThread), | |
| }); | |
| if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | |
| setIsStoppingBackgroundWork(false); | |
| const error = squashAtomCommandFailure(result); | |
| setThreadError( | |
| activeThread.id, | |
| error instanceof Error ? error.message : "Failed to stop background work.", | |
| ); | |
| } | |
| }, [activeThread, environmentId, interruptThreadTurn, setThreadError]); | |
| const handleStopBackgroundWork = useCallback(async () => { | |
| if (!activeThread) return; | |
| setIsStoppingBackgroundWork(true); | |
| const result = await interruptThreadTurn({ | |
| environmentId, | |
| input: buildThreadTurnInterruptInput(activeThread), | |
| }); | |
| if (result._tag === "Failure") { | |
| setIsStoppingBackgroundWork(false); | |
| if (!isAtomCommandInterrupted(result)) { | |
| const error = squashAtomCommandFailure(result); | |
| setThreadError( | |
| activeThread.id, | |
| error instanceof Error ? error.message : "Failed to stop background work.", | |
| ); | |
| } | |
| } | |
| }, [activeThread, environmentId, interruptThreadTurn, setThreadError]); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/components/ChatView.tsx` around lines 4209 - 4224, Update
handleStopBackgroundWork so every interruptThreadTurn failure resets
isStoppingBackgroundWork, including when isAtomCommandInterrupted(result) is
true, while preserving the existing error-setting path only for non-interrupted
failures.
| "Awaiting Input": 4, | ||
| Working: 3, | ||
| Connecting: 3, | ||
| Monitoring: 3, |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Print the full THREAD_STATUS_PRIORITY map.
rg -nP -A14 'THREAD_STATUS_PRIORITY' apps/web/src/components/Sidebar.logic.tsRepository: pingdotgg/t3code
Length of output: 1026
🏁 Script executed:
#!/bin/bash
set -e
echo "== Sidebar.logic.ts outline around resolveProjectStatusIndicator && statuses =="
rg -n "function resolveProjectStatusIndicator|resolveProjectStatusIndicator\\(|ThreadStatusPill|Working|Connecting|Monitoring" apps/web/src/components/Sidebar.logic.ts
echo
echo "== Relevant function context =="
sed -n '120,150p;666,692p' apps/web/src/components/Sidebar.logic.tsRepository: pingdotgg/t3code
Length of output: 2522
Keep Monitoring below active work priorities.
Working, Connecting, and Monitoring all use priority 3, and resolveProjectStatusIndicator only advances on strict >. Sorting by priorities can therefore return the first Monitoring entry instead of an active Working/Connecting entry for the project group. Assign Monitoring a lower priority, or change the tie-break to prefer active work statuses.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/components/Sidebar.logic.ts` at line 135, Update the status
priority mapping around Monitoring so Monitoring ranks below the active Working
and Connecting statuses, or adjust resolveProjectStatusIndicator to break
equal-priority ties in favor of active work. Preserve the existing project-group
status resolution behavior for all other statuses.
| // Background work outlives the turn: fleets read as working; monitoring | ||
| // only when watch loops are the sole live work. | ||
| if (thread.backgroundLiveness === "working") { | ||
| return "working"; | ||
| } | ||
| if (thread.backgroundLiveness === "monitoring") { | ||
| return "monitoring"; | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Background-liveness checks were inserted above higher-signal states in both status resolvers. Both functions now return a background status before states that a user must act on or that report failure, so lingering background work masks them.
apps/web/src/components/Sidebar.logic.ts#L443-L450: move the twobackgroundLivenessbranches below thesession?.status === "error"check, or confirm that the server clearsbackgroundLivenesson session error.apps/web/src/components/Sidebar.logic.ts#L626-L646: move the twobackgroundLivenesspill branches below thehasPlanReadyPromptcheck so an actionable plan prompt still wins.
📍 Affects 1 file
apps/web/src/components/Sidebar.logic.ts#L443-L450(this comment)apps/web/src/components/Sidebar.logic.ts#L626-L646
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/components/Sidebar.logic.ts` around lines 443 - 450, Reorder the
backgroundLiveness branches in both status resolvers in
apps/web/src/components/Sidebar.logic.ts:443-450 and
apps/web/src/components/Sidebar.logic.ts:626-646. In the first resolver, keep
session?.status === "error" ahead of background statuses; in the second, keep
hasPlanReadyPrompt ahead of the background-liveness pill branches so error and
actionable states take precedence.
| function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean { | ||
| const payload = | ||
| activity.payload && typeof activity.payload === "object" | ||
| ? (activity.payload as Record<string, unknown>) | ||
| : null; | ||
| if (!payload) { | ||
| return false; | ||
| } | ||
| // A task owned by an agent (a subagent's own background shell) is | ||
| // agent-internal regardless of bypass tagging. | ||
| if ( | ||
| (activity.kind === "task.started" || | ||
| activity.kind === "task.progress" || | ||
| activity.kind === "task.updated" || | ||
| activity.kind === "task.completed") && | ||
| typeof payload.agentId === "string" && | ||
| payload.agentId.trim().length > 0 | ||
| ) { | ||
| return true; | ||
| } | ||
| if (payload.timelineBypass === true) { | ||
| // Bypassed agent lifecycle rows still feed the spawn CTA: collapse folds | ||
| // every such row into its batch's single row, so letting them through | ||
| // preserves the quiet-timeline invariant while giving Codex children — | ||
| // whose rows are ALL bypassed — a CTA anchor (wire-probe finding: no | ||
| // CTA ever formed for a Codex fleet). task.started included so the CTA | ||
| // anchors at the spawn point, not the first progress tick. | ||
| const isAgentTaskRow = | ||
| (activity.kind === "task.started" || | ||
| activity.kind === "task.progress" || | ||
| activity.kind === "task.completed") && | ||
| typeof payload.taskId === "string" && | ||
| !isBackgroundTaskActivity(payload); | ||
| return !isAgentTaskRow; | ||
| } | ||
| return typeof payload.agentId === "string" && payload.agentId.trim().length > 0; | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Find provider emissions that set both agentId and taskType on task payloads.
rg -nP -C6 '\bagentId\s*[:=]' apps/server/src/provider --glob '*.ts' | rg -nP -C6 'taskType'Repository: pingdotgg/t3code
Length of output: 2658
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== candidate files =="
fd -a 'session-logic\.ts|subagentRuntime\.ts' . | sed 's#^\./##'
echo
echo "== session-logic outline/section =="
for f in $(fd 'session-logic\.ts' .); do
echo "--- $f ---"
wc -l "$f"
sed -n '630,735p' "$f" | cat -n
done
echo
echo "== subagentRuntime relevant section =="
for f in $(fd 'subagentRuntime\.ts' .); do
echo "--- $f ---"
wc -l "$f"
sed -n '130,190p' "$f" | cat -n
done
echo
echo "== all definitions/usages of isBackgroundTaskActivity =="
rg -n "isBackgroundTaskActivity|backgroundTask|taskType|agentId" -g '*.ts' .Repository: pingdotgg/t3code
Length of output: 28534
🏁 Script executed:
#!/bin/bash
set -euo pipefail
python3 - <<'PY'
import re
from pathlib import Path
subagent = Path("packages/client-runtime/src/state/subagentRuntime.ts").read_text()
session = Path("apps/web/src/session-logic.ts").read_text()
# Extract definitions for behavioral inspection as data.
is_non_agent_match = re.search(r'function isNonAgentTaskType\(taskType: string \| undefined\): boolean {\s*(.*?)\n}', subagent, re.S)
is_bg_match = re.search(r'export function isBackgroundTaskActivity\(payload: Record<string, unknown>\): boolean \{([\s\S]*?)(?=\n})\n\}', subagent)
bg_branch = re.search(r'if \(ownedByAgent\) \{([\s\S]*?)\n\}', is_bg_match.group(1), re.S)
internal_branch = re.search(r'if \(\s*\(([\s\S]*?)&& payload\.agentId\.trim\(\)\.length > 0\s*\)\s*\)\s*\{\s*return true;\n\s*\}', session, re.S)
print("isNonAgentTaskType taskTypes:")
if is_non_agent_match:
types = re.findall(r'["\']([^"'\s]+)["\']', is_non_agent_match.group(1))
print(types)
owned_bg_branch = bg_branch.group(1).strip() if bg_branch else "<missing>"
print("\nownByAgent branch body:")
print(owned_bg_branch)
print("\ninternal branch contains isBackgroundTaskActivity:", "isBackgroundTaskActivity(payload)" in internal_branch.group(1) if internal_branch else False)
print("\ninternal agentId raw condition:", bool(internal_branch))
# Static invariant checks over literal source strings.
ownedByAgent_has_is_non_agent_task_type = "isNonAgentTaskType(taskType)" in owned_bg_branch and "taskType === undefined" in owned_bg_branch
agentBranch_excludes_is_background = bool(internal_branch) and "isBackgroundTaskActivity(payload)" not in internal_branch.group(1)
agentBranch_contains_agentId_condition = bool(internal_branch) and re.search(r'typeof\s+payload\.agentId\s*===\s*["\']string["\']', internal_branch.group(1))
agentBranch_contains_nonempty_agentId_condition = bool(internal_branch) and re.search(r'payload\.agentId\.trim\(\)\.length\s*>\s*0', internal_branch.group(1))
print("\nownedByAgent branch uses taskType absent-or-non-agent classifier:", ownedByAgent_has_is_non_agent_task_type)
PYRepository: pingdotgg/t3code
Length of output: 363
Keep nested agent task rows in the activity/CTA classification.
payload.agentId marks ownership, not shell-only behavior here. Runtime classifier keeps rows with an agent task type, so hiding all task.* rows that only carry agentId removes nested agents and their anchors. Reuse isBackgroundTaskActivity() in this branch so work-log, Agentic Activities, and spawn CTA use the same task type contract.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/session-logic.ts` around lines 674 - 710, Update
isAgentInternalActivity so agentId ownership alone does not classify nested
agent task rows as internal; use isBackgroundTaskActivity() to distinguish
shell-only background tasks from agent task activity. Preserve nested agent rows
and their CTA anchors while keeping work-log, Agentic Activities, and spawn CTA
classification consistent with the shared task-type contract.
| const TASK_COMPLETED_STATUS: Record<string, RuntimeSubagentStatus> = { | ||
| completed: "completed", | ||
| failed: "failed", | ||
| stopped: "interrupted", | ||
| }; |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Guard the TASK_COMPLETED_STATUS lookup against inherited keys.
TASK_COMPLETED_STATUS is a plain object literal, and line 632 indexes it with an unvalidated payload string. A payload with status: "toString" (or "constructor") resolves to an inherited Function value. That value is truthy, so ?? "completed" does not apply, and applyStatus stores a function as the agent status. STATUS_VISUALS[status] in apps/web/src/components/AgentsPanel.tsx (line 57 and line 143) then returns undefined and the panel throws on .dotClass. The module comment on line 431 confirms payloads are not schema-validated on the read path.
Validate the resolved value instead of trusting the lookup.
🛡️ Proposed fix using a null-prototype map and an explicit check
-const TASK_COMPLETED_STATUS: Record<string, RuntimeSubagentStatus> = {
- completed: "completed",
- failed: "failed",
- stopped: "interrupted",
-};
+const TASK_COMPLETED_STATUS: ReadonlyMap<string, RuntimeSubagentStatus> = new Map([
+ ["completed", "completed"],
+ ["failed", "failed"],
+ ["stopped", "interrupted"],
+]);- const status = TASK_COMPLETED_STATUS[asString(payload.status) ?? ""] ?? "completed";
+ const status = TASK_COMPLETED_STATUS.get(asString(payload.status) ?? "") ?? "completed";Also applies to: 632-632
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/client-runtime/src/state/subagentRuntime.ts` around lines 469 - 473,
Update the status resolution in the flow using TASK_COMPLETED_STATUS to validate
that the looked-up value is a valid RuntimeSubagentStatus before passing it to
applyStatus. Guard against inherited keys such as “toString” and “constructor”
so invalid payload statuses fall back to “completed,” rather than relying on
truthiness or nullish coalescing alone.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 5d68958. Configure here.
| // progress row per task instead of thousands, so a large fleet's | ||
| // ticks can no longer evict its own start/terminal rows out of | ||
| // the 500-row retention window. | ||
| id: EventId.make(`task-progress:${event.payload.taskId}`), |
There was a problem hiding this comment.
Cross-thread progress ID collision
High Severity
Stable progress activity ids are keyed only by taskId (task-progress:… / tool-progress:…), but projection_thread_activities.activity_id is a global primary key whose upsert also rewrites thread_id. Claude task ids are short session-local values, and this file already scopes the same identity via providerTaskKey(threadId, taskId). A collision moves or overwrites another thread’s progress row.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 5d68958. Configure here.



Problem
When a thread spawns subagents, runs a workflow, or drives Codex collab agents, the UI showed nothing useful: subagent tool calls and narration interleaved anonymously into the parent chat, progress ticks spammed the work log, background shells masqueraded as agents, the sidebar showed no status once the turn settled, and Stop only killed the parent turn while the fleet kept burning tokens.
Spec (final, decisions locked): https://n0hbggyouhn1.postplan.dev
Solution
Surface agents using only native provider emissions on the current orchestrator — no dependency on orchestrator v2 (#2829), zero migrations, zero new tables. Widened
task.*activity payloads ride the existing event-sourced activity path; a client-side fold inclient-runtimederives orchestration-v2-shaped subagent state (field names match #4779, so the v2 merge is a mapper swap).Server
TaskAgentLinkageon all task payloads (role, model, workflow name/phases, run handles, output file, owningagentId), newtask.updatedevent, typed usage, tool attribution (agentId/parentToolUseId)task_updated, resolvesparent_tool_use_idon tool events, keeps subagent narration out of the parent transcript (leak + Working-timer-reset fix), attributes subagent-internal shells to their owning agent, defensiveworkflow_progressparse, Workflow run handlesthread/started/subAgentActivity(root-thread guard from live probe), intercepts child notifications, synthesizes the sametask.*lifecycle (idle = resumable, cumulative usage, real names from agentPath)Query.stopTask) and interrupts every live Codex child turn before the parent turnThreadBackgroundLivenessService(in-memory, no persistence) exposesbackgroundLiveness: working | monitoringon the thread shell — fleets read Working, watch loops read Monitoringorchestration.getWorkflowScriptRPC with TOCTOU-safe containment (open-then-verify inode, realpath under~/.claude/projects, .js-only, size cap)Web
{} scriptopens a read-only script viewtimelineBypassrows fold into the CTA, background shells stay ordinary work-log rowsMobile: same quiet-timeline fold;
task.completedkept as terminal signal.Verification
Remaining gaps
<pre>(no Shiki)forwardSubagentText/agentProgressSummariesoff🤖 Generated with Claude Code (Claude Fable 5)
Note
Add native subagent and workflow observability with background liveness tracking and Agents panel
task.started,task.progress,task.updated, andtask.completedevents carrying agent linkage (model, effort, role, toolUseId, runHandles) and typed usage metrics for subagents and workflow members.ThreadBackgroundLivenessServicethat tracks per-thread live task state and exposes a'working' | 'monitoring' | nullstatus consumed by sidebar status pills and snapshot queries viaOrchestrationThreadShell.backgroundLiveness.AgentsPanel.tsx) in the web app showing fleet status, live elapsed timers, token counts, and activity text; a CTA row collapses concurrent agent spawns into a single grouped entry in the chat timeline.getWorkflowScriptWebSocket RPC with path containment and size-capped reads, gated behindAuthOrchestrationReadScope.interruptTurnnow stops all live child tasks/turns before interrupting the parent, and Codex's interrupt handler mirrors this for collab child threads.task.progressactivity IDs changed fromevt-task-progresstotask-progress:<taskId>for upsert semantics; persisted activity rows written before this change will not be updated by new ticks.Macroscope summarized 5d68958.
Note
High Risk
Large orchestration and provider-adapter surface area plus a new file-read RPC and interrupt semantics that affect live token spend; changes are well-tested but touch security-sensitive path containment and session control.
Overview
Native subagent and workflow observability on the current orchestrator: widened
task.*activity payloads (linkage fields,task.updated, stable per-task progress ids, tool attribution) flow through ingestion; Claude and Codex adapters now emit and map collab/workflow traffic without interleaving subagent narration into the parent chat.Server behavior changes:
ThreadBackgroundLivenessderivesworking/monitoringfor thread shells;interruptTurnstops live child tasks (ClaudestopTask, Codex child turn interrupts) before the parent; newgetWorkflowScriptRPC reads contained.jsscripts under~/.claude/projects.Web: Agents right-panel (
AgentsPanel), spawn CTA rows in chat (exempt from turn folds), quiet timeline (agent-internal / bypass rows folded away), composer Stop when background work outlives the turn, sidebar Working / Monitoring pills.Mobile mirrors the quiet work-log rules (per-
taskIdcollapse, terminal Codextask.updatedrows kept).Reviewed by Cursor Bugbot for commit 5d68958. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by CodeRabbit
New Features
Bug Fixes